Skip to content

Release 0.2.0 — Remote Access E2E alpha, AI workers gallery, task language#16

Merged
tmlxrd merged 49 commits into
mainfrom
dev
May 31, 2026
Merged

Release 0.2.0 — Remote Access E2E alpha, AI workers gallery, task language#16
tmlxrd merged 49 commits into
mainfrom
dev

Conversation

@tmlxrd

@tmlxrd tmlxrd commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Promotes devmain. Merging triggers publish.ymlnpm publish @iclawapp/iclaw@0.2.0 (currently published: 0.1.4). 49 commits.

Highlights

Remote Access (E2E alpha)

  • OPAQUE passphrase login + encrypted transport (AES-256-GCM) between browser and local iClaw; relay sees only ciphertext + envelope metadata.
  • Trusted-device sessions (Ed25519). Auto-generated/persisted OPAQUE server setup — no manual config.
  • Headless onboarding + consistent startup UI.

Security audit fixes (client side of the relay hardening)

  • Tunnel ownership proof + widened tunnelId (H1 — blocks subdomain hijack on reconnect).
  • Device key stored as a non-extractable CryptoKey (L8 — XSS can't exfiltrate device identity).

Features

  • "AI workers" templates gallery (hidden preamble, MCP, logos, tool filter).
  • Tasks: OpenClaw now replies in the language the user wrote in.

Fixes: settings/sidebar search, chat-list virtualization jitter, Remote Access UX polish.

Pre-merge state

  • CI green on dev HEAD (5798407).
  • Local: 366 tests pass, typecheck + build clean.
  • Remote Access connection verified live by maintainer.

Note

Version in package.json is already 0.2.0; on merge publish.yml publishes it (skips if already published). Consider updating CHANGELOG for 0.2.0.

🤖 Generated with Claude Code

tmlxrd and others added 30 commits May 26, 2026 13:56
…auto-close

Two hidden power features get a softer learning curve:

  1. Long-press Send → Scheduled message / Create task
  2. Right-click a chat → Share / Rename / Mark unread / Delete

Three layers of help, all dismissible:

  - Discovery pills next to each entry point, themed in `--apple-blue`
    like other inline-banner CTAs:
      * "Did you know? Hold Send for more" — gated server-side by an
        "ever-created" metric (>= 2 tasks AND >= 3 scheduled msgs),
        read from sqlite_sequence so it survives row deletion. Browser
        throttles to once per day.
      * "Tip: right-click a chat for options" — pure client gate;
        first contextmenu OR hover-hold on a chat-item flips the
        discovered flag forever.

  - Hover-hold (1.5 s) opens the same menus as long-press / right-click,
    so users who don't think to click-and-hold still find them. Send
    button uses mouseenter/mouseleave; chat-items delegate via
    mouseover/mouseout with same-target de-dup.

  - Hover-intent auto-close (3.5 s) on both menus. Mouse leaves the
    menu → 3.5 s countdown; re-enter → cancel. Replaces the schedule
    menu's old 10 s blanket timeout.

Plus dropped the native `title="<chat.title>"` tooltip on sidebar chat
items — full title is visible inline, and the browser tooltip got in
the way of the new hover-hold gesture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The browser tab icon is now canvas-rendered:

  - Apple-style rounded corners (28% radius), always — even when idle.
  - Up to two status dots, aggregated across all chats/tasks and
    de-duped by color: orange (needs-human) > blue (chat ready /
    task-review) > green (working). Each dot gets a light ring so it
    reads on any base color.

Implemented as a derived view of the sidebar status dots already in the
DOM — no parallel state. Repaints only when the computed verdict
actually changes (200ms debounce), and the dots are static (never
animated), so steady-state cost is zero. Colors are read from live CSS
tokens, so dark theme is respected. Hooks into the existing
setWorkingDot / setUnreadDot / applyTasksNavSignals paths; no new
server events or routes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…fail

The home screen rendered a dead-end "Start OpenClaw" message with no input
when agents.list failed, while the badge still claimed "connected" — because
the badge used the unauthenticated /health probe while the real RPC went over
the WS. A gateway reachable on /health but unreachable over WS (wrong port,
token mismatch) looked connected but left the user stuck with no clue why.

- Derive an honest gatewayStatus (ok/degraded/down): /health alone no longer
  claims "connected" when the authenticated agents.list RPC can't get through.
- Surface the failure: log the baseUrl we tried (a wrong port is the usual
  culprit) and show a plain-language banner naming the address, with raw error
  kept under a "Details" line for support.
- When the gateway is unreachable, show only the error banner + Try again
  instead of inviting "Choose a project" for a chat that would fail to send.
- Show the same banner on the Projects page; share the logic via a new
  probeGateway() helper and a gatewayError partial.
- Reserve the sidebar "Start OpenClaw" banner for a genuinely-offline gateway
  (degraded gets the in-page banner — there's nothing to start when it's up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Opt-in via env: set ICLAW_REMOTE_ACCESS=1 and ICLAW_RELAY_URL=ws(s)://…/tunnel
and iClaw will open an outbound WebSocket to the relay on startup. The relay
allocates a temporary subdomain, forwards public HTTP requests as `req`
frames, and we replay them against the local Express server over loopback
and stream the response back as `res` frames.

- src/services/remoteAccess.ts — outbound WS client, frame protocol, loopback
  forwarder, exponential-backoff reconnect, idempotent start/stop.
- src/index.ts — wire start() after listen, stop() into graceful shutdown.

Raw byte-forwarding only; no auth/encryption layer yet. Invite token,
SPAKE2 password handshake and per-device keypair sessions land in follow-up
commits on this branch.
Block anyone who reaches iClaw through the relay until they prove they
know the host-side passphrase. Direct localhost users are never
challenged — only requests carrying `x-iclaw-tunneled: 1` (set by our
own loopback forwarder, never trusted from the public side) hit the
gate.

- src/services/remoteAccessAuth.ts (new)
  * 4-word + 3-digit diceware passphrase (~35 bits, online-only attack)
  * in-memory session store, 24h TTL, swept every 5 min
  * 10-attempts-per-5-min per-IP login rate limit
  * constant-time passphrase compare
  * self-contained inline login HTML (no external assets, so 401 doesn't loop)
  * HttpOnly + SameSite=Lax cookie; Secure flag added when behind https
- src/app.ts
  * mount auth middleware after body parsers, BEFORE static so assets
    are gated too
  * mount POST /__ra/login handler
- src/services/remoteAccess.ts
  * generate passphrase on start() and print it prominently in the log
  * strip incoming x-iclaw-* from frame headers (defence in depth with
    the relay-side strip that lands in iclaw-relay)
  * inject x-iclaw-tunneled: 1 on the loopback request
  * disableGate() on stop()

Threat model (v0.1): relay is honest-forwarder; it can technically read
the passphrase as it streams through during login because we haven't
moved to SPAKE2 yet. Full E2E "relay never sees the password" lands as
a follow-up. Today's win is: anyone who finds the subdomain URL without
the passphrase still can't see anything.
Pairs with the relay-side WS bridge. iClaw's UI uses /ws for live chat
streaming and gateway events; without this, remote-access only delivered
the initial HTML and the page sat there static.

What lands:
- Three new tunnel frames recognised in the message handler:
  ws-open, ws-data, ws-close (s-XXXX stream-id namespace, distinct from
  request ids).
- On ws-open: defence-in-depth strip of any incoming x-iclaw-*; run the
  same password-gate session check used by HTTP middleware
  (isValidTunnelSession in remoteAccessAuth, newly exported); if invalid,
  send ws-close 4401 and bail. Otherwise open a loopback WebSocket to
  ws://<localHost>:<localPort><frame.path> with `x-iclaw-tunneled: 1`
  injected.
- Per-stream pending queue: ws-data frames that arrive while the loopback
  handshake is still in CONNECTING state get buffered and flushed on
  'open' instead of being silently dropped. Without this, a fast public
  client racing the loopback handshake would lose its first message.
- ws-data / ws-close routing in both directions; cleanup on either side
  notifies the other.

Threat model unchanged: relay sees ciphertext-of-TLS (operator-trust),
the password gate is enforced end-to-end of the local iClaw.

E2E smoke: WS via tunnel with cookie → ping/pong round-trip; without
cookie → close 4401; direct localhost WS unaffected.
End-users now enable / disable Remote Access from inside iClaw — no env
vars, no restart. Pick a duration (30 min / 12 h / 7 d / 30 d) and the
tunnel auto-stops when it expires; iClaw remembers the state across
restarts so a 30-day tunnel survives quitting the app.

Surface:
- GET /remote-access                — Settings page (in sidebar nav)
- GET /api/remote-access/status     — current state + allowed durations
- POST /api/remote-access/start     — { durationMs } from the fixed list
- POST /api/remote-access/stop      — fully tear down + clear persisted state

Pieces:
- src/db/database.ts                — new singleton `remote_access_state`
                                      table (enabled / passphrase / duration /
                                      started_at / expires_at)
- src/services/remoteAccessState.ts — typed get/save/clear over the row
- src/services/localAddress.ts      — stash bound host:port so the API
                                      doesn't need to know about server
                                      internals
- src/services/remoteAccess.ts      — refactored:
                                        * startWithDuration() — UI path,
                                          persists + schedules auto-stop
                                        * resume() — startup re-attach
                                          when expires_at is still in the
                                          future (clears the row otherwise)
                                        * stopNow() — UI "Disable" — closes
                                          socket + drops the gate + clears
                                          persisted state
                                        * stop() — graceful shutdown only;
                                          keeps the persisted row so we
                                          auto-resume on next start
                                        * getStatus() — surface for the API
                                        * ALLOWED_DURATIONS_MS — the four
                                          fixed values; API rejects anything
                                          else
                                        * getRelayUrl() — env override or
                                          ws://127.0.0.1:4100/tunnel default
- src/routes/remoteAccessApi.ts     — JSON API (zod-free, simple
                                      number-in-allow-list validation)
- src/routes/remoteAccessPage.ts    — GET /remote-access page, reuses the
                                      sidebar/head/foot partials so it
                                      looks like the rest of the app
- src/index.ts                      — on startup: register bound address,
                                      then either resume() from DB or
                                      fall back to the legacy env path
- views/remote-access.ejs           — settings UI: duration picker when
                                      disabled; URL + passphrase + expiry
                                      countdown + Disable button when on.
                                      Inline JS polls /status every 1.5s
                                      so the URL appears live once the
                                      relay handshake completes, and the
                                      remaining-time counter ticks down.
- views/partials/remoteAccess{Disabled,Enabled}.ejs — first-paint
                                      contents of the card (script
                                      re-renders on state changes).
- views/partials/sidebar.ejs        — "Remote access" link in the main nav.

E2E smoke (17 sub-checks): page renders, status disabled by default,
POST start gets URL + passphrase, browser-friendly URL via lvh.me hits
the login gate, login + 200 iClaw HTML, settings page shows enabled
state with the URL, sidebar link present, POST stop clears state, URL
404s after stop, invalid duration → 400.

Persistence (4 sub-checks): kill iClaw with an active tunnel; restart;
state comes back enabled with the original passphrase; iClaw reconnects
to the relay (which assigns a fresh subdomain since the previous WS is
gone); "resuming (UI)" log line confirms the resume path.

Threat model unchanged from the password-gate slice. Passphrase is
stored plaintext in the local SQLite — same trust assumption as every
other iClaw secret.
…-affect

Singleton tunnel → collection. A user can now create as many tunnels as
they want, each with its own duration, passphrase, public URL, and
connected sessions. Disabling one never affects another.

Schema:
- Drop singleton remote_access_state (kept as a dead table for in-place
  upgrades; can be removed in a follow-up).
- New remote_access_tunnels(id PK, label, passphrase, duration_ms,
  started_at, expires_at, created_at) with idx on expires_at.

State:
- remoteAccessState rewritten: list / get / save / delete / clearAll
  over the new collection. Tunnel ids are random `t-xxxxxxxxxx`.

Auth (per-tunnel):
- passphrases: Map<tunnelId, passphrase>; enableGate/disableGate per id.
- sessions: Map<sessionId, {tunnelId, expiresAt}>; the middleware
  verifies that a presented session id belongs to the exact tunnel id
  the request is for, so a hand-crafted cross-tunnel cookie replay
  is refused even though browser cookie scoping already prevents the
  common case.
- New `x-iclaw-tunnel-id` header (injected by the loopback forwarder,
  stripped from any incoming public request) tells the middleware
  which tunnel's gate to consult.
- Login rate-limit keyed on (ip, tunnelId) so flooding one tunnel
  doesn't burn another's budget.

Runtime (multi-tunnel):
- remoteAccess.ts rewritten around `Map<tunnelId, RuntimeTunnel>`. Each
  RuntimeTunnel owns its own outbound WS, expiry timer, currentUrl, and
  per-stream loopback connections.
- createTunnel(durationMs, label?) — fresh id+passphrase, save, connect,
  schedule expiry.
- deleteTunnel(id) — closes WS, clears its sessions, removes from DB.
- resumeAll() — at startup, re-attach every persisted tunnel whose
  expires_at is still in the future. Expired rows are deleted.
- shutdown() — graceful: close everything in memory but DON'T touch
  persisted rows (so the next startup auto-resumes).
- Legacy env-driven activation (ICLAW_REMOTE_ACCESS=1) is removed; UI is
  the only way to enable now.

API:
- GET    /api/remote-access/tunnels       → { tunnels, allowedDurationsMs }
- POST   /api/remote-access/tunnels       → { durationMs, label? } → 201 status
- DELETE /api/remote-access/tunnels/:id   → { ok: true } | 404
- Old /start /stop /status endpoints removed; UI fully on the new ones.

UI:
- views/remote-access.ejs rewritten as a list: top "create new" card
  (duration radios + button) followed by a card per active tunnel
  with URL, passphrase (copy buttons), live expiry countdown, and a
  per-tunnel "Disable this tunnel" button.
- Polling continues every 1.5s while the page is visible; remaining
  time ticks every second without re-rendering the whole list.
- Legacy partials remoteAccessDisabled/Enabled removed.

Relay: no changes — multi-tunnel was already supported on its side (each
WS connection from iClaw gets its own subdomain in the relay's hub).

E2E smoke (20 sub-checks):
- create A (30 min) + B (12h) → distinct ids, distinct passphrases,
  distinct URLs;
- log in on A → 200 on A;
- A's passphrase rejected on B (per-tunnel passphrase isolation);
- A's cookie rejected on B (per-tunnel session isolation);
- log in on B → both work independently;
- DELETE A → A becomes 404, B keeps serving 200;
- DELETE B → list empty;
- DELETE nonexistent → 404; bad duration → 400.
Remove the standalone "Remote access" sidebar link and route. The feature
moves into a Settings page reached via a small gear button positioned to
the left of "New chat" — the entry point Apple-style apps train users to
look for.

Sidebar:
- New `.sidebar-settings-btn` (icon-only, 38×38, mirrors the existing
  search-toggle's visual language so the toolbar reads as one row). Has
  an `.active` state when on the Settings page.
- Removed the explicit "Remote access" item from `.sidebar-projects-nav`.

Routing:
- `routes/settings.ts` mounts `GET /settings` (and is wired into app.ts
  in place of the old remoteAccessPageRouter).
- `views/settings.ejs` replaces the standalone Remote Access page.
- Legacy `routes/remoteAccessPage.ts` + `views/remote-access.ejs`
  removed entirely. `/remote-access` now 404s.
- The JSON API (`/api/remote-access/tunnels` etc.) is untouched — the
  page move is purely a URL / UI change, not a feature change.

Design (Settings page):
- iOS-y segmented-control style duration picker (pill rail with a
  selected segment, no radios visible) instead of separate radio cards.
- iOS blue (#0a7aff) primary pill for "Create tunnel"; destructive red
  (#ff453a) for "Disable".
- Tunnel cards are softly tinted surfaces with hairline dividers between
  rows (no heavy borders), a tiny green dot + monospaced short id in
  the header, and a quiet right-aligned remaining-time counter.
- 34px title, generous 56px gap below it, 540px content column —
  whitespace doing the structural work instead of boxes.
- Mobile breakpoint: stacked picker + full-width primary button.

E2E smoke (14 sub-checks): /settings renders with title + RA section +
create form; /remote-access 404s; sidebar has gear linking to /settings,
no legacy RA item; active class on /settings; API endpoints unchanged;
created tunnel shows up in the server-rendered card on next page load.
The previous pass leaned too hard on an iOS aesthetic that didn't match
the rest of the app. Rewritten to use iClaw's own design tokens and
component classes.

Sidebar gear:
- Shrink from 38×38 with 20×20 icon → 28×28 with 16×16 icon. Borderless
  ghost-style with subtle hover background, matching how iClaw's other
  inline icons behave.

Settings page:
- Layout container mirrors `.projects-hub-page` (max-width 32rem,
  centered, padding via --space tokens).
- Title: 1.5rem / 600 / letter-spacing -0.03em — same as projects-hub-title.
- Section heads use --text-sm description below a 1.05rem title; no more
  oversized iOS-style 34px hero.
- Buttons switched to native `.btn` `.btn--primary` `.btn--danger`
  `.btn--ghost` `.btn--sm` instead of custom ra-*-btn classes. Disable
  is now the iClaw danger pattern (subtle red background tint, not
  destructive red text).
- Status indicator uses the existing `.chip` `.chip--ok` chip.
- Tunnel cards use `.card` with hairline row dividers via `--border`
  color-mix.
- Duration segmented control replaced with simple bordered options that
  pick up `--accent` when selected — fits with how iClaw already
  highlights selected items elsewhere.
- All colors via CSS vars (--text, --muted, --border, --surface,
  --accent, --md-link, --danger); the old #0a7aff / #ff453a / #30d158
  hex literals are gone.

E2E sanity (7 checks served HTML):
  ✓ uses btn / btn--primary / btn--ghost / btn--sm
  ✓ uses chip--ok
  ✓ uses .card
  ✓ no legacy ra-*-btn classes
  ✓ no iOS hex literals
  ✓ gear button present
  ✓ gear is 28×28 in CSS
The previous pass shipped a functional but bland page — internal #id
treated as data, all rows visually identical, URL hidden among labels,
empty state was a plain "No active tunnels" line.

Changes:

Tunnel card
- Drop the internal `#aabbcc` ID from the header — it's not useful to
  the user, just noise. Header is now just the Live chip + remaining
  time on the right.
- "Enabled" chip → "Live" with a pulsing dot. Conveys "this is actually
  serving right now" rather than just a setting state.
- Card promoted to `card--accent` so live tunnels read as the live thing
  on the page (subtle left-border accent + tinted bg).
- URL row redesigned as a "hero" block: full-width card-in-card with an
  accent-tinted background, monospaced URL, and an external-link icon
  on the right of the link itself (so you can tell it's clickable).
  Hover lifts the bg + icon color.
- Copy buttons are now icon-only 32×32 squares with subtle hover state;
  flip to a green checkmark with success bg for 1.2s after copy. Less
  visual weight than text+icon, more findable than plain text.
- Passphrase row promoted to a 3-column grid so the label, value and
  copy button line up regardless of passphrase length.
- "Disable tunnel" is a ghost button by default and only turns red on
  hover, so a destructive action doesn't dominate the card visually.
- Remaining-time text reads "29s left" / "11h 56m left" / "6d 23h left"
  instead of bare "29s" — clearer without an explicit "Expires in".

Create-tunnel form
- "+" icon on the primary button so the action reads as "add", not just
  generic "Create".
- Slightly tighter padding on the wrap.

Empty state
- Proper empty state with a small unlink icon + headline + hint
  ("Create one above to start sharing access."), dashed border around
  it. The earlier <p>No active tunnels.</p> looked broken next to the
  filled cards.

Misc
- Page max-width 32rem → 36rem so the URL doesn't ellipsis-truncate on
  desktop.
- Pulse animation on the Live dot (gentle 2s breathe).
- Spinner on "Connecting to relay…" instead of just a ⟳ glyph.
- Mobile breakpoint: passphrase row stacks; form goes vertical.

Same iClaw native tokens (`.btn`, `.card`, `.chip`, `--text`, `--muted`,
`--accent`, `--border`, `--ok`, `--danger`, `--md-link`, `--surface`,
`--space-N`, `--radius-md`). No iOS hex literals, no own design system.
…styling

Three things from the latest pass of feedback:

1) "Прибиту картку" — softer card styling
- Drop the `card--accent` left-border. Cards are now plain surface with
  a hairline border that slightly darkens on hover.
- URL block keeps a prominent inset look but uses a neutral text-tinted
  background instead of the accent-tinted one (less "alarm" feel).
- Passphrase row now also gets a matching inset block + copy button,
  giving the two main rows the same visual rhythm.
- "Live" status is now a small green dot + word (pulse animation kept)
  instead of a chip — softer, less status-bar-y.
- "Disable" is a tiny ghost link in the footer rather than a button.
- Tunnel card padding bumped from 4 to 5 + 4 for breathing room.
- Hover gives a 1px shadow nudge, not a heavy lift.

2) "Менше часу — вище" — sort by expiresAt
- `remoteAccess.list()` now sorts by expiresAt ascending (createdAt as
  tiebreaker). A 30-min tunnel made now will sit above a 7-day tunnel
  made yesterday, and tunnels close to expiring rise as time passes.

3) "Створити тунель це спливаюче меню" — native modal
- "New tunnel" is now a primary button next to the section title
  instead of an inline form. Clicking it opens a `<dialog>`-based modal.
- Modal fields:
  - Name (optional, max 64 chars, free text). Empty stays NULL in DB.
  - Duration (4 radio chips, 12 hours preselected). 2×2 grid.
- Submit posts {durationMs, label} to the API (which already accepted
  label since the multi-tunnel slice). Cancel / ✕ / backdrop click /
  Escape all close the modal cleanly.
- Tunnel card now shows the label inline in the head row between the
  Live indicator and the remaining-time counter (ellipsis-truncates if
  too long).
- Backdrop uses a subtle dim + 4px backdrop-filter blur; modal itself
  has a 0.18s scale-up open animation.

Empty state stays its own dashed-border block; "+ inline form" was
removed entirely, so a fresh iClaw with no tunnels looks intentional
rather than half-filled.

Same iClaw native tokens throughout (`.btn`, `.btn--primary`,
`.btn--ghost`, `--surface`, `--border`, `--text`, `--muted`, `--accent`,
`--ok`, `--danger`, `--md-link`, `--space-N`, `--radius-md/lg`,
`--text-xs/sm/md`). No iOS hex literals.

E2E smoke: labels survive the round-trip ({"label":"Anna Mobile"} in
response), sort order places a 30-min tunnel above a 7-day tunnel,
settings page markup contains the modal + open button + label class.
Pairs with the relay-side multiplexing refactor. Previously each
RuntimeTunnel held its own outbound WS, leading to per-tunnel reconnect
storms and rate-limit starvation when more than a couple tunnels were
active.

Now one module-level WS for the whole iClaw process; every
RuntimeTunnel is just an entry in tunnels:Map<tunnelId,RuntimeTunnel>
and frames flowing in/out carry the tunnelId so we can demultiplex.

Lifecycle:
- configure(opts) — set bound relay URL + local host/port.
- resumeAll() on startup — load persisted tunnels whose expiresAt is
  in the future, register them once the shared WS opens.
- createTunnel(durationMs, label?) — mint id+passphrase, persist,
  send register-tunnel (or queue until WS opens via ensureConnection).
- deleteTunnel(id) — send unregister-tunnel, clear local state,
  drop persisted row.
- shutdown() — graceful process exit; closes WS without touching
  persisted rows.

Reconnect:
- On socket 'close' we mark every tunnel's currentUrl = null so the
  UI shows "Connecting…" again, then scheduleReconnect() with
  exponential backoff.
- On the next 'open' we re-register every tunnel in tunnels:Map and
  set currentUrl back when tunnel-registered comes in. Subdomains
  will be fresh — the passphrase/tunnelId stay the same.

Header injection on loopback unchanged: x-iclaw-tunneled: 1 +
x-iclaw-tunnel-id: <id> so the password gate knows the request came
through the tunnel and which tunnel it belongs to.

Per-stream WS bridging unchanged shape, just keyed by tunnelId
instead of being implicit in the per-tunnel WS.

E2E (smoke): 4 fresh tunnels each get URLs over a single relay
connection; killing the relay forces an iClaw reconnect that
re-registers all 4 with new subdomains (passphrase preserved).
The relay is now publicly deployed on viralo behind the existing
Cloudflare Tunnel:
  - relay.iclaw.digital      → http://localhost:4100  (the relay's apex)
  - *.iclaw.digital wildcard → http://localhost:4100  (per-tunnel subdomains)

Default the iClaw client to it. Env override (ICLAW_RELAY_URL) still
wins so dev against a local iclaw-relay keeps working.

Net UX win: fresh iClaw install can enable Remote Access from the
Settings page with zero additional setup — no second terminal, no
running iclaw-relay locally.
Two polish passes that the rendered UI was visibly missing.

New-tunnel modal
- Bigger title (1.05rem semibold) + one-line subtitle explaining what
  the action does ("Generates a temporary URL and passphrase…").
- Duration picker switched from minimal pill row → 2×2 cards. Each
  card has a main label ("12 hours") + sub-hint ("today"), and the
  selected card gets a soft accent tint, an accent border AND a check
  icon in the top-right (so the choice is unambiguous at a glance).
- Field labels promoted to text-strong with a smaller, hint-style
  second line, so the meaning of "Name (optional)" / "Expires after"
  reads at a glance.
- Modal max-width 26rem → 28rem so the duration grid breathes.
- Heavier shadow + better backdrop (55% black + 6px blur) so the modal
  feels properly on top of the page.

Login page (served by iClaw, reached via tunnel)
- Added a header bar with the iClaw favicon + wordmark linking to
  https://iclaw.digital — matches how the other iClaw properties
  (landing, cloud) brand themselves at the top.
- Title is now "Enter the passphrase" with a small "Remote Access"
  eyebrow pill above it (with a pulsing accent dot), so the page
  identifies what feature it is without the prior generic wording.
- Subtle radial accent glow at the top of the page; card has a
  proper inset highlight + 24px shadow stack.
- Focused input gets a 3-px accent ring (instead of a 2-px solid
  outline that clashes with the border).
- Footer with "Powered by iclaw.digital" hyperlink.
- Local-served-not-relay reassurance is now in the card foot under a
  hairline, not floating below the form.
- Mobile breakpoint (<480px): tighter card padding, smaller header.

All native-token styling on the modal (`.btn`, `.card`, `--space-*`,
`--text`, `--muted`, `--accent`); the login page is intentionally
self-contained inline CSS (it has to render even if iClaw's main
stylesheet is unreachable through the tunnel).
Plain link/password rows instead of input-style fields, click-to-reveal
passphrase, smarter expiry labels, Share CTA, and clearer section text
without duplicate security footnote or Live badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
Polish the share sheet (grouped name, segmented duration, required label),
simplify tunnel cards and empty state, add adaptive polling, and require
label on tunnel creation in the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use the shared design system (style.css) with grouped-field layout,
allow gate assets before auth, and drop the standalone blue theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace fixed-interval polling with nextPollIn scheduling, setTimeout
chaining, expiry-aware delays, error backoff, and a 5-minute sanity cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
routes.projects.test.ts mocked `services/openclaw` without `health()`,
but src/routes/projects.ts → probeGateway → openclaw.health() now needs
it (probeGateway was widened a while back). Two tests have been failing
on dev as a result:

  TypeError: openclaw.health is not a function
    at probeGateway (src/services/gatewayProbe.ts:30)
    at GET /projects handler

  - "renders the hub even when no projects exist"
  - "lists projects, sorted by activity"

Mirror the shape used by the other gateway-exercising tests
(routes.gateway.test.ts, routes.taskAsk.test.ts): add
`health: vi.fn(async () => true)` to the mocked openclaw.

After:  303/303 pass.
Ping the relay every 30s so Cloudflare does not drop idle tunnel
connections. Keep the last public URL in the UI during brief outages;
relay sticky restore returns the same subdomain on re-register.

Co-authored-by: Cursor <cursoragent@cursor.com>
Slide-out sidebar with backdrop, compact chat header (icons on phone,
text on desktop, chat title hidden on narrow screens), and safe-area
padding across settings, projects, tasks, and composer.

Co-authored-by: Cursor <cursoragent@cursor.com>
…essions

Remove legacy trusted-relay mode and REMOTE_ACCESS_E2E flag. Tunneled login
uses OPAQUE; app traffic uses encrypted HTTP/WS. Add vendor copy/verify,
device trust/revoke, access token gate, and unit/smoke tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
…fixes

Lands the E2E-only Remote Access alpha and the fixes from the security
audit. Tunneled HTTP/WS payloads are encrypted browser↔local-iClaw; the
relay forwards ciphertext only and never sees the passphrase.

Feature:
- OPAQUE (RFC 9807, @serenity-kit/opaque) passphrase login — passphrase
  never crosses the wire; tunneled POST /__ra/login is rejected (426).
- Encrypted HTTP (/__ra/e2e/http) + WS (/__ra/e2e/ws) transport with a
  browser fetch/WebSocket shim; plaintext over the tunnel limited to a
  small exempt set (gate assets, OPAQUE/device/e2e bootstrap,
  unauthenticated GET /).
- Device sessions (browser keypair; private key never leaves the browser)
  with per-device revoke.
- Vendored crypto assets (@noble/hashes, @serenity-kit/opaque) with
  copy + verify scripts in pre/postinstall.

Post-audit fixes:
- C1 (critical) AES-GCM nonce reuse: the 12-byte nonce is derived from
  (tunnelId, counter) only, and counters restart per stream, so two
  streams reused (key, nonce) under one direction key — catastrophic for
  GCM and trivially decryptable by the relay. Fixed by deriving a
  per-(direction, streamId) AEAD subkey via HKDF from the direction key,
  in both src/services/remoteAccessE2eCrypto.ts and the browser mirror
  public/js/ra-e2e-crypto.mjs (byte-identical; verified vendored noble
  HKDF == npm noble HKDF). Corrected the false "XChaCha20" header
  comment. Regression test: two streams, same ctr → distinct ciphertext
  + cross-stream decrypt fails.
- H4 (high) fail-fast: POST /api/remote-access/tunnels returns
  503 opaque_setup_missing when OPAQUE_SERVER_SETUP is absent, instead
  of minting a tunnel nobody can log into.
- H2 (high) honest UI copy: the Settings security panel no longer claims
  cookie privacy; it now states the relay sees metadata + access/session
  cookies, while passphrase and payloads stay private.
- H3 (high) docs/REMOTE_ACCESS.md written (setup, OPAQUE_SERVER_SETUP,
  local vs host, security model, troubleshooting); UI no longer points
  at a missing file.

Known open (tracked, NOT fixed here):
- H1: the relay still sees the iclaw_ra cookie (Set-Cookie on OPAQUE
  finish + outer E2E requests). It is HttpOnly and not load-bearing for
  the E2E data path (inner requests authenticate via the OPAQUE transport
  session), so content stays private after C1 — but the cookie exposure
  remains. Safe removal needs a relay→browser→iClaw integration test
  first; changing the auth path blind risks breaking login.

Tests: 353 pass (incl. C1 regression). typecheck clean.
In the native (non-codex) agent loop the gateway commits each assistant
segment as its own transcript row, so the preamble that precedes a
tool_use is emitted as an intermediate session.message / chat:final
BEFORE the tools run. runTurn settled on those (1s grace), truncating the
turn to just the preamble and dropping the real answer that follows
several tool round-trips. Codex hid the loop and emitted one final at the
end, so it was unaffected.

Gate turn settlement on lifecycle:end — the one canonical run terminator
that fires once after every tool round-trip. chat:final / session.message
now only capture text; they re-arm the short settle grace only after
lifecycle:end. Drop the now-redundant 15s end watchdog.

Add native-loop regression tests and update existing completion tests to
emit lifecycle:end as the terminator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Keep the live scheduled list in chronological order after add, edit, and page load.

Co-authored-by: Cursor <cursoragent@cursor.com>
tmlxrd and others added 19 commits May 29, 2026 17:53
…aintext path

Plaintext tunnel requests are never authenticated now: strip iclaw_ra on the
loopback so a stale session cookie can't cause an 'E2E transport required'
dead-end or serve the workspace in the clear. Any top-level HTML navigation
(any path, incl. /chats/:id) is answered with the gate bootstrap, which resumes
the E2E session from sessionStorage (no passphrase on same-tab nav/reload/back)
or prompts for the passphrase. Data XHRs stay forced to E2E.

- Device verify no longer Set-Cookies iclaw_ra (leaked to the relay and can't
  establish E2E keys); returns needsPassphrase instead.
- Passphrase login reuses the registered device instead of minting a duplicate
  on every login; revoked devices are dropped from local IndexedDB.
- installRaE2eTransport is idempotent (was double-wrapping fetch -> recursion
  after document.write); unified transport import URL + cache-buster ra-gate-6.
- Browser<->server crypto.subtle interop test (loads real ra-e2e-crypto.mjs).
- copy-opaque-vendor strips dangling sourcemap comments.
- Remove the Settings security panel (model lives in docs/REMOTE_ACCESS.md).
- Integration test asserts nav=200, no iclaw_ra leak, concurrent sessions,
  returning-visitor + deep-link bootstrap, and C1/C2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface pending send-later state in the chat list with live WS updates,
working > unread > scheduled priority, and favicon sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces fuchsia so the indicator stays visible but does not compete with unread blue.

Co-authored-by: Cursor <cursoragent@cursor.com>
The settings page omitted the shared client bundle that wires sidebar search and live chat list updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
The access URL remains a plain link that opens in a new tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
… config)

A fresh install no longer needs a manual `npx create-server-setup` + env step.
The OPAQUE server setup is now resolved as: OPAQUE_SERVER_SETUP env override ->
value persisted in the local SQLite -> lazily generated and stored on first
tunnel creation. The env var still wins (advanced / shared-host: set the same
value on several machines to share a tunnel/passphrase).

- ensureOpaqueServerSetup(): lazy mint+persist, single-flight; loadServerSetup()
  prefers env then DB.
- POST /api/remote-access/tunnels auto-provisions instead of returning the H4
  503 'opaque_setup_missing'; the 503 ('opaque_setup_unavailable') now only
  signals a genuine OPAQUE-runtime failure.
- Startup bootstrap also ensures a setup when tunnels exist (e.g. a previously
  set env was removed) so resumed tunnels re-register and stay loggable.
- Works on any OS iClaw runs on (OPAQUE is WASM; stored in the existing SQLite).
- Tests: auto-gen + env-precedence; reframed the service-layer guard test.
- docs/REMOTE_ACCESS.md: setup is automatic, env documented as optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…workspace)

After an E2E-delivered page is written to the DOM, the browser loads its
stylesheets/scripts/icons via <link>/<script> tags, which bypass the fetch
wrapper and so can't be E2E-wrapped. They were hitting the plaintext path and
getting 426 'E2E transport required' -> the workspace rendered with no CSS/JS
('can't see messages').

Serve public app-shell assets (/css/*, /js/*, favicons) in the clear from BOTH
gates (the tunnel plaintext-exempt check AND the gate middleware). They carry
no user data (identical for every visitor). User data stays E2E: HTML pages,
/api, /uploads, /media, WebSocket.

- isPublicStaticAsset(): single source of truth in remoteAccessAuth, used by
  the gate middleware and isE2ePlaintextTunnelExempt.
- Tests: unit (static vs user-data boundary) + integration (/js/iclaw.js served,
  /uploads stays E2E-gated).
- docs: security model updated (app-shell plaintext, user data E2E).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The E2E WebSocket wrapper compared u.origin !== location.origin, but iclaw.js
opens the socket with an absolute wss:// URL whose origin ('wss://host') never
equals the page origin ('https://host'). So every chat WebSocket fell through
to a direct /ws connection, which iClaw rejects (4406 'encrypted WebSocket
required') -> endless reconnect loop -> messages never sent/received. (fetch was
fine: iclaw.js uses relative URLs, whose origin matches.)

Compare host instead of origin so same-host wss:// is routed through the
encrypted /__ra/e2e/ws bridge. Bump gate cache-buster to ra-gate-7 so browsers
pull the fixed transport (kept all transport import URLs identical for the
single-module-instance idempotency guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add [ra-dbg] logging (gated by localStorage.ra_debug='1' or ?radebug=1) across
ra-device-auth init + ra-e2e-transport navigateViaE2eDocument/installRaE2eTransport
to trace where the browser-side E2E resume stalls (server + protocol already
verified working end-to-end). Logs the previously-swallowed resume error.
Cache-buster -> ra-gate-8. No behaviour change when the flag is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns/manifest)

The static allowlist missed root files like /icon-192.png (dynamic favicon),
which 426'd. Broaden isPublicStaticAsset to root single-segment files with a
static extension (png/ico/svg/webmanifest/json/woff…) — covers favicons, PWA
icons and the manifest while never matching user data (/uploads,/media,/api or
page routes are multi-segment / extension-less).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd')

Add [ra-dbg:iclaw] logging (gated by localStorage.ra_debug='1' or ?radebug=1)
around connectWs (open/close/error + readyState), wsSend (type + readyState +
open?), and the E2E boot→connectWs trigger. Pinpoints whether the chat
WebSocket actually reaches OPEN in the browser (it does in a Node client), which
is the gate for sends to flush. No behaviour change when the flag is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iclaw.js was loaded without a cache-buster, so browsers (and Cloudflare)
served a stale copy — which is why the [ra-dbg:iclaw] logs never appeared.
Add ?v=dbg1 to the iclaw.js script tag in all views, and log every WS frame
iclaw.js receives ([ra-dbg:iclaw] WS recv) plus every frame the E2E transport
wrapper decrypts/dispatches ([ra-dbg:transport] WS recv … decrypt OK/FAIL,
listeners N). Gated by localStorage.ra_debug. Lets a clean single-message test
show iclaw.js's real WS lifecycle (open/recv/wsSend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p-alive; drop debug

Three browser-transport bugs blocked sending over the encrypted tunnel:

- WebSocket.OPEN was undefined after the shim replaced window.WebSocket
  (static readyState constants weren't copied), so `ws.readyState ===
  WebSocket.OPEN` was always false and wsSend() never transmitted. Copy
  CONNECTING/OPEN/CLOSING/CLOSED onto the replacement constructor.

- socket.send() base64-encoded string frames with btoa(), which throws on
  non-Latin1 input; a .catch() swallowed it, so any message with non-ASCII
  content (e.g. Cyrillic) was silently dropped. UTF-8 encode before base64
  (mirrors the inbound TextDecoder path), chunked for large payloads.

- Add a 25s app-level WS ping so idle sockets survive Cloudflare's ~100s
  WebSocket idle timeout, including gaps between agent stream frames.

Also remove the temporary diagnostics used to find these (server-side file
logging, /__rtrace client beacons, gated [ra-dbg] console logging) and tidy
the asset cache-busters (iclaw.js ?v=1; gate/transport assets ?v=ra-gate-9
so clients pick up the transport fix).

Verified end-to-end over a live tunnel (Ukrainian message -> full agent turn)
plus 365 unit tests and the RA E2E integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
remoteAccessToken.test.ts imported the relay's hashAccessToken from the
sibling iclaw-relay repo via a relative path. That resolves in local dev
(both repos checked out side by side) but not in CI, which builds iClaw
alone — the missing module failed the whole suite (and had been failing
dev CI on the last few pushes).

Load the relay module via a guarded dynamic import and gate the parity
assertion with it.skipIf: it still runs locally (verifying iClaw and the
relay hash access tokens identically) and skips cleanly in CI instead of
erroring. iClaw's own hash stays pinned to a spec-derived fixture in a
separate always-on assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When iClaw starts on a machine with no local browser (a headless server) it now
offers, in the startup-banner style, to bring up a private remote-access link so
the operator can reach it from a phone or laptop without ever opening a browser
on the box. Detection is automatic — no DISPLAY/WAYLAND on Linux means headless;
macOS/Windows always have a browser.

Startup UI is driven by environment, not by how it was launched:
- interactive terminal + local browser (npx OR npm run dev) -> the polished
  banner. Raw-mode key controls (g to open the browser) attach only on a real
  CLI launch (npx/iclaw); under `npm run dev` tsx-watch owns stdin, so the banner
  shows without them and Ctrl+C falls back to the normal signal.
- headless server with a terminal -> the remote-access onboarding: a [Y/n]
  splash (Yes creates a 30-day link, No falls back). The terminal stays
  interactive — S shows the link(s), Ctrl+C stops. Link + passphrase auto-hide
  after a minute; resting screens show the local address and active-link count.
  Already-configured restart: a quiet confirmation, no re-printed secret.
- piped / service start (systemd, docker) -> a functional one-line log + a hint.

Also:
- Fix scheduleExpiry overflowing setTimeout's 2^31-1 ms (~24.8-day) ceiling — a
  30-day link was firing its expiry immediately; clamp and re-arm the remainder.
- Quiet routine remote-access info logs while onboarding owns the terminal.
- Always restore the terminal's raw mode on exit (survives tsx-watch reloads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(remote-access): headless onboarding + consistent startup UI
Client side of the relay subdomain-hijack fix. The relay previously
restored a reconnecting tunnel on tunnelId alone, letting a stranger who
learned the (short, relay-logged) tunnelId claim the subdomain. Now each
tunnel carries a 256-bit ownership secret, sent to the relay on
register-tunnel as `ownerProof` (relay stores SHA-256 only) and required
for any cross-connection restore/rotate.

- DB: new owner_secret column (idempotent ensureColumn migration).
- remoteAccessState: persist/backfill ownerSecret (ensureOwnerSecret);
  generateTunnelId widened from 5 bytes (40-bit) to 16 bytes (128-bit)
  so the id is no longer a practical targeting primitive.
- remoteAccess: thread ownerSecret through RuntimeTunnel and emit it as
  ownerProof on every register (create / resume / reconnect).

Local-only secret; never leaves the machine except as a hash. Backward
compatible with relays that ignore the field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trusted-device Ed25519 key was generated extractable and persisted in
IndexedDB as an exportable pkcs8 blob, so an XSS on the tunnel could read
the device identity straight out of storage and impersonate the browser.

Generate the keypair non-extractable and persist the live CryptoKey
(structured-clone keeps it opaque in IndexedDB); the private key never
leaves WebCrypto. Public key stays exportable for spki registration.

Backward compatible: resolveSigningKey() and all device-presence checks
accept either the new CryptoKey (privateKey) or a legacy pkcs8 blob
(privateKeyJwk), so already-trusted browsers keep working until their next
passphrase login mints a non-extractable key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task plan/execution/ask prompts didn't pin a response language, so OpenClaw
often defaulted to English even when the user wrote the goal and chat in
another language (e.g. Ukrainian).

Add a shared language policy ("mirror the user's language") injected into all
three task LLM prompts: buildTaskPlanPrompt, buildExecutionPrompt, and the
Ask first-turn message. The policy is split per call site so machine-parsed
tokens stay intact while only human-facing prose is translated:
  - plan: keep the `agent:` / `human:` step prefixes English, titles localized
  - execution: keep protocol markers (TASK_DONE, NEEDS_HUMAN, ASK_USER,
    NEEDS_REVIEW, ADD_HUMAN_STEP) verbatim, explanation localized
  - ask: pure prose, no tokens

Chat-title generation already instructs "use the same language as the user",
so it's left as-is. No JS language detection (brittle across scripts) — the
model mirrors reliably.

typecheck clean; taskRunner tests pass (13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tmlxrd
tmlxrd merged commit 1de6c1a into main May 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant